"""The dockable Three Blocks panel — account, store, and managed tools.

C4D can only register plugins at application startup, so the hub owns the whole
UI surface: one async `GeDialog` the CommandData opens (and `RestoreLayout`
brings back inside a saved layout). Managed tools contribute rows and
sub-dialogs to it rather than registering their own panels (plan §5).

All state changes arrive through `events` and are applied in `CoreMessage` on
the main thread. The row/card *content* is computed by host-free helpers
(`account_lines`, `tool_rows`) — they are the testable surface, and they say
the same things the Blender sidebar says.
"""

from __future__ import annotations

import sys
import webbrowser

import c4d

from . import auth, bridge, catalog, events, host, manager, ops, prefs
from ._core import api, config

# Gadget ids. C4D wants stable ints, not names — keep the blocks obvious.
GROUP_ROOT = 1000
GROUP_ACCOUNT = 1001
GROUP_FOOTER = 1002
GROUP_BADGE = 1003
GROUP_TOOLS = 1004
BUTTON_REFRESH = 1100
BUTTON_SITE = 1101
BUTTON_LOGIN = 1102  # doubles as "Cancel sign-in" while waiting
BUTTON_SIGNOUT = 1103
BUTTON_BRIDGE = 1104
GROUP_BRIDGE = 1005
TEXT_BRIDGE = 2500
TEXT_ACCOUNT = 2000  # dynamic account lines get TEXT_ACCOUNT + n
TEXT_BADGE = 2100
TEXT_TOOLS = 2200
TEXT_NOTE = 2300
TEXT_FOOTER = 2400
ROW_GROUP_BASE = 2600  # per-row layout groups
ROW_LABEL_BASE = 2700  # per-row labels
INSTALL_BASE = 3000  # per-row buttons get BASE + row index
REMOVE_BASE = 3200
OPEN_BASE = 3400

# Static fallback rows until the first catalog fetch lands — same tools the
# server registry (dcc-catalog.ts C4D_ADDONS) lists today.
_FALLBACK_ADDONS = (
    {
        "id": "oav",
        "module": "utsubo_oav",
        "displayName": "Object Animation Video",
        "description": "bake rigid-object animation",
        "available": False,
    },
    {
        "id": "splat",
        "module": "utsubo_splat",
        "displayName": "Mesh to Splat",
        "description": "bake the scene into a Gaussian splat",
        "available": False,
    },
    {
        "id": "utsbv",
        "module": "utsubo_utsbv",
        "displayName": "Baked Motion",
        "description": "bake .utsbv motion",
        "available": False,
    },
)

_booted = False


def account_lines(site: str, verdict=None, phase: str = "idle", message: str = "") -> list[dict]:
    """What the account card says: [{text, muted?}] — host-free on purpose.

    Naming precedence matches Blender H2: verdict user name > email >
    device-credential name > token prefix — never the raw token.
    """
    if phase == "waiting":
        lines = [{"text": "Waiting for your browser…"}]
        if message:
            lines.append({"text": message, "muted": True})
        return lines
    credential = config.load_credential(site)
    if not credential:
        lines = [{"text": "Not signed in"}]
        if phase == "error" and message:
            lines.append({"text": message})
        lines.append({"text": "Opens your browser · one seat per person", "muted": True})
        lines.append({"text": "(or run `three-blocks login` in a terminal)", "muted": True})
        return lines
    who = api.display_name(verdict) or credential.get("name") or credential.get("tokenPrefix") or "your account"
    lines = [{"text": f"Signed in as {who}"}]
    if verdict:
        plan = verdict.get("planDisplay") or verdict.get("plan") or "unknown plan"
        entitled = bool(verdict.get("entitled"))
        lines.append(
            {"text": f"{plan} — {'Pro downloads available' if entitled else 'No Pro download access'}"}
        )
    return lines


def tool_rows(entries, installed_version, verdict_entitled: bool, job_addon_id, installed_modules=()) -> list[dict]:
    """Row models for the store list — the Blender `_draw_tools` logic, host-free.

    states: installed | locked | coming_soon | installable. `installed_version`
    is a callable (module) -> version-or-None so this stays import-light.
    """
    if entries is None:
        entries = [{**addon, "entitled": verdict_entitled} for addon in _FALLBACK_ADDONS]
    # A tool that is installed but absent from the feed (offline, an unpublished
    # catalog, or a hand-provisioned render farm) must still show a row —
    # otherwise it is running with no way to open or remove it.
    known = {entry.get("module") for entry in entries}
    entries = list(entries) + [
        {"id": module, "module": module, "displayName": module, "entitled": True, "available": False}
        for module in installed_modules
        if module not in known
    ]
    rows = []
    for addon in entries:
        entitled = bool(addon.get("entitled"))
        available = bool(addon.get("available"))
        module = addon.get("module")
        installed = installed_version(module)
        title = addon.get("displayName") or addon.get("id") or "Tool"
        description = addon.get("description")
        label = f"{title} — {description}" if description else title
        can_update = bool(
            installed and entitled and available and catalog.newer_version(addon.get("version"), installed)
        )
        if installed and not can_update:
            state = "installed"
        elif not entitled:
            state = "locked"
            label = label
        elif not available:
            state = "coming_soon"
            label = f"{label} — Coming soon"
        else:
            state = "installable"
        rows.append(
            {
                "id": addon.get("id", ""),
                "module": module or "",
                "title": title,
                "label": label,
                "state": state,
                "installed": bool(installed),
                "busy": job_addon_id == addon.get("id"),
                "enabled": state in ("installed", "installable") and not job_addon_id,
            }
        )
    return rows


def tools_note(rows, job_message: str, signed_in: bool) -> str:
    if job_message:
        return job_message
    if any(row["state"] == "installable" or row["installed"] for row in rows):
        return "Installed tools open from this panel."
    if signed_in:
        return "Managed Cinema 4D tools are coming soon."
    return "Sign in to download Pro tools."


class HubDialog(c4d.gui.GeDialog):
    def __init__(self):
        super().__init__()
        self._rows: list[dict] = []
        self._tool_dialogs: dict[str, object] = {}
        self._status: str = ""

    # --- layout ---------------------------------------------------------------

    def CreateLayout(self) -> bool:
        self.SetTitle("Three Blocks")
        self.GroupBegin(GROUP_ROOT, c4d.BFH_SCALEFIT | c4d.BFV_SCALEFIT, 1)
        self.GroupBorderSpace(8, 8, 8, 8)

        self.GroupBegin(GROUP_ACCOUNT, c4d.BFH_SCALEFIT, 1)
        self.GroupEnd()

        self.GroupBegin(GROUP_BADGE, c4d.BFH_SCALEFIT, 1)
        self.GroupEnd()

        self.GroupBegin(GROUP_TOOLS, c4d.BFH_SCALEFIT, 1)
        self.GroupEnd()

        self.GroupBegin(GROUP_BRIDGE, c4d.BFH_SCALEFIT, 1)
        self.GroupEnd()

        self.GroupBegin(GROUP_FOOTER, c4d.BFH_SCALEFIT, 2)
        self.AddButton(BUTTON_REFRESH, c4d.BFH_SCALEFIT, name="Refresh")
        self.AddButton(BUTTON_SITE, c4d.BFH_SCALEFIT, name="Open threejs-blocks.com")
        self.GroupEnd()

        self.GroupEnd()
        return True

    def InitValues(self) -> bool:
        global _booted
        if not _booted:
            _booted = True
            manager.cleanup_staging()
            manager.register_installed()  # local imports only — no network
        # Opening the panel is an explicit gesture: refresh the account verdict
        # and catalog for signed-in users. Signed-out stays fully local.
        if config.load_credential(host.site_url()):
            auth.refresh_verdict_async()
            catalog.check_async()
        self.refresh()
        return True

    # --- group rebuilds (the documented flush → re-add → changed flow) --------

    def refresh(self) -> None:
        self.refresh_account()
        self.refresh_badge()
        self.refresh_tools()
        self.refresh_bridge()

    def refresh_bridge(self) -> None:
        """The `three-blocks c4d …` live lane, off until you turn it on."""
        self.LayoutFlushGroup(GROUP_BRIDGE)
        if bridge.running():
            self.AddStaticText(
                TEXT_BRIDGE, c4d.BFH_LEFT, name=f"Live bridge on — 127.0.0.1:{bridge.port()}"
            )
            self.AddStaticText(
                TEXT_BRIDGE + 1, c4d.BFH_LEFT, name="`three-blocks c4d graph` reads this document."
            )
            self.AddButton(BUTTON_BRIDGE, c4d.BFH_SCALEFIT, name="Turn off the live bridge")
        else:
            self.AddStaticText(TEXT_BRIDGE, c4d.BFH_LEFT, name="Live bridge off")
            self.AddStaticText(
                TEXT_BRIDGE + 1, c4d.BFH_LEFT, name="Let the three-blocks CLI read this document."
            )
            self.AddButton(BUTTON_BRIDGE, c4d.BFH_SCALEFIT, name="Turn on the live bridge")
        self.Enable(TEXT_BRIDGE + 1, False)
        self.LayoutChanged(GROUP_BRIDGE)

    def refresh_account(self) -> None:
        site = host.site_url()
        signed_in = config.load_credential(site) is not None
        self.LayoutFlushGroup(GROUP_ACCOUNT)
        lines = account_lines(site, auth.VERDICT, auth.STATE["phase"], auth.STATE["message"])
        for index, line in enumerate(lines):
            self.AddStaticText(TEXT_ACCOUNT + index, c4d.BFH_LEFT, name=line["text"])
            if line.get("muted"):
                self.Enable(TEXT_ACCOUNT + index, False)
        if auth.STATE["phase"] == "waiting":
            self.AddButton(BUTTON_LOGIN, c4d.BFH_SCALEFIT, name="Cancel sign-in")
        elif signed_in:
            self.AddButton(BUTTON_SIGNOUT, c4d.BFH_SCALEFIT, name="Sign out")
        else:
            self.AddButton(BUTTON_LOGIN, c4d.BFH_SCALEFIT, name="Sign in to Three Blocks")
        self.LayoutChanged(GROUP_ACCOUNT)

    def refresh_badge(self) -> None:
        self.LayoutFlushGroup(GROUP_BADGE)
        if catalog.update_available():
            self.AddStaticText(
                TEXT_BADGE, c4d.BFH_LEFT, name=f"Hub update available — v{catalog.hub_latest()}"
            )
            self.AddStaticText(
                TEXT_BADGE + 1, c4d.BFH_LEFT, name="Run `three-blocks install c4d`, then restart Cinema 4D."
            )
            self.Enable(TEXT_BADGE + 1, False)
        self.LayoutChanged(GROUP_BADGE)

    def refresh_tools(self) -> None:
        site = host.site_url()
        signed_in = config.load_credential(site) is not None
        verdict_entitled = bool((auth.VERDICT or {}).get("entitled"))
        self._rows = tool_rows(
            catalog.addons(),
            manager.installed_version,
            verdict_entitled,
            ops.JOB["addon_id"],
            installed_modules=sorted(manager.installed_modules()),
        )
        self.LayoutFlushGroup(GROUP_TOOLS)
        self.AddStaticText(TEXT_TOOLS, c4d.BFH_LEFT, name="Tools")
        for index, row in enumerate(self._rows):
            self.GroupBegin(ROW_GROUP_BASE + index, c4d.BFH_SCALEFIT, 4)
            self.AddStaticText(ROW_LABEL_BASE + index, c4d.BFH_SCALEFIT | c4d.BFH_LEFT, name=row["label"])
            if not row["enabled"]:
                self.Enable(ROW_LABEL_BASE + index, False)
            if row["state"] == "installed":
                if self._tool_ui(row["module"]):
                    self.AddButton(OPEN_BASE + index, c4d.BFH_RIGHT, name="Open")
                self.AddButton(REMOVE_BASE + index, c4d.BFH_RIGHT, name="Remove")
            elif row["state"] == "installable":
                self.AddButton(
                    INSTALL_BASE + index,
                    c4d.BFH_RIGHT,
                    name="Installing…" if row["busy"] else "Update" if row["installed"] else "Install",
                )
                if row["installed"]:  # an update on offer must not hide Remove
                    self.AddButton(REMOVE_BASE + index, c4d.BFH_RIGHT, name="Remove")
            self.GroupEnd()
        note = self._status or tools_note(self._rows, ops.JOB["message"], signed_in)
        self.AddStaticText(TEXT_NOTE, c4d.BFH_LEFT, name=note)
        self.Enable(TEXT_NOTE, False)
        self.AddStaticText(TEXT_FOOTER, c4d.BFH_LEFT, name=f"hub v{host.hub_version()} · {host.label()}")
        self.Enable(TEXT_FOOTER, False)
        self.LayoutChanged(GROUP_TOOLS)

    def _tool_ui(self, module: str):
        """A loaded managed tool's optional UI hook: TOOL_UI = {title, create_dialog}."""
        loaded = sys.modules.get(module) if module else None
        ui = getattr(loaded, "TOOL_UI", None)
        return ui if isinstance(ui, dict) and callable(ui.get("create_dialog")) else None

    # --- actions --------------------------------------------------------------

    def Command(self, cid: int, msg) -> bool:
        if cid == BUTTON_REFRESH:
            self._status = ""
            # Also pick up tools provisioned outside the panel (a headless
            # `install`, a shared render-farm folder) without a restart.
            manager.register_installed()
            auth.refresh_verdict_async()
            catalog.check_async()
            self.refresh()
        elif cid == BUTTON_SITE:
            webbrowser.open(host.site_url())
        elif cid == BUTTON_LOGIN:
            if auth.STATE["phase"] == "waiting":
                auth.cancel_login()
            else:
                ok, message = auth.start_login()
                self._status = "" if ok else message
            self.refresh()
        elif cid == BUTTON_SIGNOUT:
            self._sign_out()
        elif cid == BUTTON_BRIDGE:
            self._toggle_bridge()
        elif INSTALL_BASE <= cid < INSTALL_BASE + 200:
            self._install(cid - INSTALL_BASE)
        elif REMOVE_BASE <= cid < REMOVE_BASE + 200:
            self._remove(cid - REMOVE_BASE)
        elif OPEN_BASE <= cid < OPEN_BASE + 200:
            self._open_tool(cid - OPEN_BASE)
        return True

    def _sign_out(self) -> None:
        if not c4d.gui.QuestionDialog(
            "Sign out of Three Blocks?\n\nThe credential is shared with the three-blocks CLI, "
            "so this signs the CLI out as well. Installed tools stay local."
        ):
            return
        existed = config.clear_credential(host.site_url())
        auth.set_verdict(None)
        catalog.check_async()  # anonymous re-fetch → install/update rows lock again
        self._status = "Signed out (the three-blocks CLI too)" if existed else "No local credential to remove"
        self.refresh()

    def _toggle_bridge(self) -> None:
        """Start/stop the loopback bridge and remember the choice for next launch."""
        try:
            if bridge.running():
                bridge.stop()
                prefs.set_bridge_autostart(False)
                self._status = "Live bridge off."
            else:
                port = bridge.start()
                prefs.set_bridge_autostart(True)
                self._status = f"Live bridge on — 127.0.0.1:{port}"
        except Exception as error:
            self._status = f"Could not toggle the live bridge: {error}"
        self.refresh()

    def _install(self, index: int) -> None:
        row = self._row(index)
        if not row:
            return
        ok, message = ops.start_install(row["id"])
        self._status = "" if ok else message
        self.refresh_tools()

    def _remove(self, index: int) -> None:
        row = self._row(index)
        if not row:
            return
        if not c4d.gui.QuestionDialog(f"Remove {row['title']}?"):
            return
        dialog = self._tool_dialogs.pop(row["module"], None)
        if dialog is not None:
            try:
                dialog.Close()
            except Exception:
                pass
        ok, message = ops.remove_tool(row["id"], row["module"])
        self._status = message
        self.refresh_tools()

    def _open_tool(self, index: int) -> None:
        row = self._row(index)
        ui = self._tool_ui(row["module"]) if row else None
        if not ui:
            return
        dialog = self._tool_dialogs.get(row["module"])
        if dialog is None:
            dialog = ui["create_dialog"]()
            self._tool_dialogs[row["module"]] = dialog
        slot = sorted(self._tool_dialogs).index(row["module"])
        dialog.Open(c4d.DLG_TYPE_ASYNC, pluginid=prefs.TOOL_DIALOG_ID_BASE + slot, defaultw=360, defaulth=280)

    def _row(self, index: int) -> dict | None:
        return self._rows[index] if 0 <= index < len(self._rows) else None

    # --- the main-thread drain ------------------------------------------------
    # events.push(kind, payload) kind routing (the one worker→UI channel):
    #   "login"   → auth.handle_login_event (+ browser open on "open_url")
    #   "install" → ops.finish_install
    #   "tool"    → (module, stage, detail) from a managed tool's worker, routed to
    #               THAT module's sub-dialog only — three tools share this queue, so
    #               a broadcast would report a Baked Motion bake into Mesh to Splat
    #   "verdict" / "catalog" → payload-less redraw pokes
    # Every kind ends in refresh(); workers must never touch the GUI directly.

    def CoreMessage(self, cid: int, msg) -> bool:
        if cid == prefs.HUB_ID:
            for kind, payload in events.drain():
                if kind == "login":
                    if payload[0] == "open_url":
                        webbrowser.open(payload[1])
                    auth.handle_login_event(payload)
                    if payload[0] == "token":
                        self._status = f"Signed in as {payload[1].get('name') or 'your account'}"
                        catalog.check_async()
                elif kind == "install":
                    ok, message = ops.finish_install(payload)
                    self._status = message
                elif kind == "tool":
                    # A managed tool reporting from its own worker: hand it to
                    # that tool's sub-dialog, and mirror it in the hub's status.
                    module, stage, detail = payload
                    reporter = getattr(self._tool_dialogs.get(module), "report", None)
                    if callable(reporter):
                        reporter(stage, detail)
                    self._status = detail
                # "verdict" and "catalog" carry no payload — they just redraw.
            self.refresh()
            return True
        return c4d.gui.GeDialog.CoreMessage(self, cid, msg)
